Why should you always test Compose performance in release?

Ben Trengrove
Android Developers

--

When discussing performance related to Compose, you’ll find that the guidance states “always test in release mode with R8 enabled”. You might notice jank when testing your app in debug and deploying from Android Studio, but switching to release seems to solve the issue. These effects seem to be greater than they were with the equivalent View-based code. So the question is: Why? The answer to that question has several layers so this blog post will demystify this guidance and detail what causes this performance gap.

Before we begin peeling back the layers, let’s quantify how much of a difference we are talking about. Using the Macrobenchmark sample, we can benchmark the scrolling list activities (View-based and Compose-based) in various configurations to see the effect each configuration has on the worst case time to startup and render the first frame. It is these worst case frames that can make your app appear janky.

You can see from these results that in debug, Compose performs much slower than in an optimal configuration. It is important to notice that the View-based activity also performs much slower than its optimal configuration as well. All debuggable code will perform slower than its equivalent optimized non-debuggable code, the effect is just magnified with Compose. But, why?

Compose is “Unbundled”

As can be seen above, there are multiple contributors to the difference in performance but the one of the biggest contributors to the gap is that Compose is unbundled — you statically link the library in your app.

We ship Compose as a library, it is unbundled from the system framework. The classic view system however is bundled with the system framework as part of Android OS.

When a user downloads your app, this includes Compose and any other libraries you are using, as well. This is what is meant by “unbundled”. The view system, on the other hand, can be seen as another part of Android, it is already on users’ devices, ahead-of-time compiled to machine code, loaded and running. In other words, it is bundled with the OS.

Unbundling Compose allows us to ship new features and bug fixes directly to you. You can then use new Compose features in your apps without having to worry about what Android OS version your users are running, as long as it’s above the minimum supported version. You are also free to adopt them at your own pace, rather than when we release them.

Shipping Compose as a library does have some tradeoffs though, particularly when it comes to performance. When building your app as `debuggable`, all the code in your app has to be interpreted, including all the library code of Compose. This means that rather than directly executing machine code, the Android runtime (ART) is reading dex code and executing it. You can think of it as an intermediary step. We call this process, interpretation. Interpreted code is slower than compiled code, as the Android runtime has to do additional work. Furthermore, ART will then convert the code that is executed the most into machine code so that it isn’t interpreted everytime. We call this Just In Time compilation, or JIT. This process also takes time and creates CPU load. You can see the additional work being done by looking at a system trace.

Debug — Note the work being done in the JIT thread pool
Debug — Note the work being done in the JIT thread pool
Debug — Tracing JIT we can see Compose classes
Debug — Tracing JIT we can see Compose classes
Release + R8 + Profile
Release + R8 + Profile

Most of the equivalent code of the View framework, however, is never run in interpreted mode because it has already been built and compiled in release and shipped with Android OS. Your View-based app code that is built as `debuggable` is referencing system code that has always already been built with release and optimized.

You might think, “but RecyclerView is shipped as a library as well, why isn’t it affected the same?”. The answer is, with views like RecyclerView and other AndroidX view libraries, only a small sliver of code is run as debuggable and interpreted. The RecyclerView specific code around managing what views are reused and recycled is run as debuggable, but the view stack underneath is all still framework code.

“View libraries quickly end up back in the pre-compiled, release built and optimized, framework code. The Compose version on the other hand is running the whole UI stack as debuggable code, not just the small sliver of lazy list management.”

In addition to the code running in interpreted mode, there is also an asymmetry in load times. Android shares common system resources, such as the view system, as part of the Zygote process, however all the classes in Compose need to be loaded into memory when starting up your app.

Compiling your app in release mode will only partially solve this problem. Since the app is no longer debuggable, it is possible for the code to be compiled by ART and run without the interpreter, but this is done lazily. ART will run all the code first in interpreted mode, and then compile important code to be run more efficiently the next time it is run. This means that even if you build the app in release mode, there is still quite a bit of difference when comparing the two. You can often see this effect at first startup, you might notice that your app appears janky at first but then starts to perform well. You are seeing the effects of ART’s interpretation.

This discrepancy is mostly resolved by Baseline Profiles and R8 (discussed in a later section). The baseline profile included with Compose will mark the majority of the Compose library as classes to preload at application start, and mark the majority of the Compose library methods as methods to compile so they are run uninterpreted. This has a huge impact on app performance.

When running from Android Studio, baseline profiles are not used and so you will not see their effect in your normal day to day development cycle. To properly test the startup behavior of your app, you can use Macrobenchmark. Macrobenchmark allows you to benchmark the startup of your app and see the benefits of running with a baseline profile. This is a more realistic test as it is what your users will experience, rather than deploying from Android Studio.

Other contributors to slower performance

Compose shipping as a library is the biggest contributor to the performance difference between debug and release, but there are other contributors that have a significant effect.

Live Literals

When an app is being built with the debug variant, Android Studio will pass a flag to the Compose compiler to generate code that is slightly more expensive in order to enable a developer tooling feature called Live Literals. Essentially, this will turn every constant literal in the generated code into a thin “getter function” instead of a constant. This is what allows Android Studio to replace literals at runtime for a smoother development experience. Kotlin, R8, and ART all make various micro-optimizations at various levels with constants that make certain code patterns much faster. By turning this into a getter function with some lightweight conditional logic, none of those optimizations are possible. Compiling the app in release mode will prevent these deoptimizations from happening.

The Compose compiler determines the stability of your Composables in order to determine if they can be skipped or not during recomposition. As part of this process, the compiler will check if the parameters passed to your Composable are static or dynamic. With Live Literals enabled, many parameters will become dynamic to allow for them to be replaced by a different literal. This does not occur in release mode and as such debug code tends to run more code in recomposition than the equivalent code compiled in release.

You might also be wondering what the effect of Live Edit is on performance. Live Edit will have an effect on performance when enabled, similar to Live Literals. However because of the way Live Edit has been architected, when the feature is disabled there shouldn’t be any performance penalty, unlike Live Literals which are tied to the debug flag.

R8

Compose also benefits tremendously from R8 optimisation. As seen above, the addition of R8 in its default configuration is a 75% gain in startup performance and 60% gain in frame rendering performance. R8 does a lot of optimisation but below are the details of some that have the greatest effect on Compose code.

Lambda Grouping

Compose embraces lambdas as a central aspect of API design. Trailing lambdas are used to create semantic nesting in composables, and many APIs have lambdas as their first-class entry point. This is in heavy contrast to the View framework, built in Java, where lambda-based APIs are much rarer and generally avoided to reduce the class count.

Lambdas result in anonymous classes being generated which implement one of many Function interfaces and have an invoke method. For example:

is converted to the following by the compiler:

For a reasonable sized Compose application, there will be many anonymous lambda classes that get created for a comparatively small amount of code. Additionally, the Compose compiler will generate additional lambdas for every restartable composable function as an implementation detail.

Lambdas can be expensive for various reasons, but one thing to keep in mind is that they are a new and distinct class. When loading a class for the first time, there is a performance penalty to pay. One way in which Compose combats this is by leveraging R8’s lambda grouping optimizations. This will take lambdas that are of the same “signature” and group them into a single lambda class whose implementation is basically a switch statement of the originally grouped classes implementations. The result is much fewer lambda classes to load at runtime, which can have a measurable performance impact.

Omitting Source Information

Among the code that Compose generates in composable functions, it generates a call to a `sourceInformation` function at the root of every composable function, including composable lambdas. This function receives a string which encodes information that is lazily evaluated and utilized by developer tooling such as Layout Inspector to calculate the source location of calls to composable functions. These methods are intentionally marked as side effect free so that R8 will omit the calls completely when it compiles Compose code. This will not only avoid the method call but also avoids the lazy evaluation of the string, which saves time.

Constant Folding

R8 has access to the whole program so for a given function, R8 knows what values were passed to it from every possible call site. Composable functions have additional synthetic parameters, some of which are Int values which are passed in as constants from the call site. These parameters are then used in conditional logic surrounding a lot of the code that the Compose compiler generates. In some situations, the R8 compiler is able to eliminate code paths that it knows are never taken as a result of knowing all possible values of that parameter.

Single Implementation Interfaces

Composable functions have an additional parameter with the type `Composer`. Composer is an interface; however there is only one implementation in the Compose library, `ComposerImpl`. This was done to allow for other implementations in the future and to allow for some API flexibility.

Almost all of the code that Compose generates ends up as calls to methods of this Composer object. Since the parameter type is an interface, all these calls end up as `invoke-virtual` in the resulting dalvik bytecode. R8, however, is smart enough to see that there is only one implementation of this interface in the entire program, so it can swap these types and change the resulting bytecode into `invoke-static` which is generally much faster.

Summing up

That’s it for now — hopefully that helped to peel back some layers of the onion that is Compose performance. Runtime performance and developer experience are very important to the Compose team. We are actively working to improve this situation when testing apps in debug.

So remember, if you see performance issues in your app, make sure to test it in release mode and even better, create a benchmark to quantify and measure the performance of configuration your users will see. It may be that you don’t have a problem at all! For more Compose performance information, check out our Compose performance guide and I/O talk. If you have an example of code that performs poorly but you believe it shouldn’t, please file an issue in the issue tracker.

--

--